import { DurableObject } from "cloudflare:workers"; const MESSAGES_STORAGE_KEY = "messages"; const CHAT_SOCKET_TAG = "chat-room"; const MAX_MESSAGES = 30; type ChatAttachment = { room: string; name: string; }; type ChatEnvelope = | { kind: "history"; room: string; messages: ChatMessage[]; } | { kind: "message"; room: string; message: ChatMessage; } | { kind: "presence"; room: string; message: string; } | { kind: "error"; message: string; }; type ChatMessage = { id: string; name: string; text: string; timestamp: string; room: string; }; export class ChatRoomDurableObject extends DurableObject { async fetch(request: Request) { const url = new URL(request.url); if ( request.headers.get("upgrade")?.toLowerCase() === "websocket" && url.pathname.startsWith("/ws-chat") ) { return this.handleWebSocketSession(url); } return new Response("Not found", { status: 404 }); } async webSocketMessage(socket: WebSocket, message: string | ArrayBuffer) { await this.handleSocketMessage(socket, message); } async webSocketClose(socket: WebSocket) { this.broadcast({ kind: "presence", room: this.getParticipantRoom(socket), message: `${this.getParticipantName(socket)} left the room.`, }); } webSocketError() { // Connection errors do not need custom handling for this demo. } private async handleWebSocketSession(url: URL) { const room = this.getRoomFromPath(url.pathname); const name = this.sanitizeName(url.searchParams.get("name")); const pair = new WebSocketPair(); const [client, server] = Object.values(pair); server.serializeAttachment({ room, name } satisfies ChatAttachment); this.ctx.acceptWebSocket(server, [CHAT_SOCKET_TAG]); await this.sendHistory(server, room); this.broadcast({ kind: "presence", room, message: `${name} joined the room.`, }); return new Response(null, { status: 101, webSocket: client, }); } private async handleSocketMessage( socket: WebSocket, message: string | ArrayBuffer, ) { const text = typeof message === "string" ? message : new TextDecoder().decode(message); const payload = this.parsePayload(text); if (!payload) { socket.send( JSON.stringify({ kind: "error", message: "Invalid chat payload.", } satisfies ChatEnvelope), ); return; } if (payload.kind !== "message") { return; } const entry: ChatMessage = { id: crypto.randomUUID(), name: this.getParticipantName(socket), text: payload.text, timestamp: new Date().toISOString(), room: this.getParticipantRoom(socket), }; await this.storeMessage(entry); this.broadcast({ kind: "message", room: entry.room, message: entry }); } private parsePayload(text: string) { try { const payload = JSON.parse(text) as { kind?: string; text?: string; room?: string; }; if ( payload.kind !== "message" || typeof payload.text !== "string" || !payload.text.trim() ) { return null; } return { kind: "message" as const, text: payload.text.trim().slice(0, 500), room: payload.room ?? "", }; } catch { return null; } } private async sendHistory(socket: WebSocket, room: string) { const messages = await this.readMessages(); socket.send( JSON.stringify({ kind: "history", room, messages, } satisfies ChatEnvelope), ); } private async readMessages() { return ( (await this.ctx.storage.get(MESSAGES_STORAGE_KEY)) ?? [] ); } private async storeMessage(message: ChatMessage) { const messages = await this.readMessages(); const nextMessages = [...messages, message].slice(-MAX_MESSAGES); await this.ctx.storage.put(MESSAGES_STORAGE_KEY, nextMessages); } private broadcast(payload: ChatEnvelope) { const encoded = JSON.stringify(payload); for (const socket of this.ctx.getWebSockets(CHAT_SOCKET_TAG)) { socket.send(encoded); } } private getParticipantName(socket: WebSocket) { const attachment = socket.deserializeAttachment() as ChatAttachment | null; return attachment?.name ?? "Anonymous"; } private getParticipantRoom(socket: WebSocket) { const attachment = socket.deserializeAttachment() as ChatAttachment | null; return attachment?.room ?? "default"; } private sanitizeName(value: string | null) { const name = value?.trim().slice(0, 40); return name || "Anonymous"; } private sanitizeRoom(value: string | null) { const room = value ?.trim() .toLowerCase() .replace(/[^a-z0-9-_]/g, "-") .slice(0, 48); return room || "default"; } private getRoomFromPath(pathname: string) { const encodedRoom = pathname.split("/").filter(Boolean)[1]; const decodedRoom = encodedRoom ? decodeURIComponent(encodedRoom) : null; return this.sanitizeRoom(decodedRoom); } }